JPA · 엔티티 매핑
ORM? 은 자바 객체와 DB 테이블을 자동으로 이어 주는 기술이고, JPA? 는 그 자바 표준입니다. 이 표준대로 어떤 클래스의 어떤 필드가 어느 테이블의 어느 열에 들어가는지 정해 주는 일이 엔티티 매핑이에요.
Jakarta Persistence 3.1 스펙 · Hibernate 6.x(구현체) · Spring Boot 3.3.1 · Java? 17.
패키지는 모두 jakarta.persistence.* 입니다. 예전 javax.persistence.* 는 쓰지 않습니다(Jakarta로 이름이 바뀌었어요).
실제 study-backend 에서도 엔티티는 core/model/entity 패키지에 두고, DB는 PostgreSQL(스키마 lds), 스키마 변경은 Liquibase로 관리합니다.
1. 엔티티 선언 — @Entity · @Table
무엇: @Entity 를 붙인 클래스가 곧 엔티티? 입니다. 클래스 하나 = 테이블 한 개, 객체 하나 = 행(row) 한 줄에 대응해요.
import jakarta.persistence.*;
@Entity
@Table(
name = "tb_user", // 테이블 이름(생략 시 클래스 이름)
schema = "lds", // PostgreSQL 스키마
indexes = { @Index(name = "ix_user_userid", columnList = "userid") },
uniqueConstraints = { @UniqueConstraint(columnNames = { "userid" }) }
)
public class User {
// ...
}@Table은 생략 가능합니다. 생략하면 테이블 이름은 엔티티 이름을 그대로 씁니다.name테이블 이름 ·schema스키마 ·indexes인덱스 목록 ·uniqueConstraints복합 유니크 제약.
JPA는 객체를 만들 때 매개변수 없는 기본 생성자로 빈 객체를 먼저 만든 뒤 값을 채웁니다. 그래서 모든 엔티티에는 기본 생성자가 있어야 하고,
접근 지정은 public 또는 protected 여야 합니다(private·final 불가). 다른 생성자를 직접 만들었다면 기본 생성자를 같이 적어 줘야 해요.
2. 기본키 — @Id · @GeneratedValue
무엇: @Id 는 "이 필드가 기본키(PK)"라는 표시, @GeneratedValue 는 "그 값을 누가 어떻게 만들지"를 정합니다.
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
strategy 의 값(GenerationType)은 네 가지입니다. 기본값은 AUTO 예요.
| 전략 | 방식 | 특징 |
|---|---|---|
IDENTITY | DB의 auto-increment 열에 맡김 | 가장 쉬움. 단, INSERT를 해야 키를 알 수 있어 여러 건을 한 번에 모아 보내는 배치 INSERT가 막힙니다. |
SEQUENCE | DB 시퀀스 객체에서 번호를 받아옴 | 미리 번호를 당겨올 수 있어 성능에 유리. PostgreSQL이 잘 지원. |
TABLE | 별도 키 관리용 테이블로 번호 발급 | 모든 DB에서 동작하지만 매번 그 테이블을 잠가야 해 가장 느립니다. 권장하지 않음. |
AUTO | 구현체(Hibernate)가 DB에 맞게 알아서 선택 | 기본값. Hibernate 6에서는 보통 SEQUENCE 쪽으로 정해집니다. |
PostgreSQL에서는 IDENTITY(컬럼 자체를 auto-increment)나 SEQUENCE(시퀀스 객체)를 주로 씁니다. SEQUENCE 를 쓸 때 시퀀스 이름·당겨올 개수를 세밀하게 정하려면 @SequenceGenerator 를 함께 답니다.
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq") @SequenceGenerator( name = "user_seq", // generator 가 가리키는 이름 sequenceName = "lds.seq_user",// 실제 DB 시퀀스 이름 allocationSize = 50 // 한 번에 50개씩 당겨와 캐시(기본값 50) ) private Long id;
name— 이 생성기의 별명.@GeneratedValue의generator와 이름이 같아야 연결됩니다.sequenceName— DB에 실제로 존재하는 시퀀스 이름.allocationSize— 한 번 DB에 다녀올 때 미리 받아 둘 번호 개수(기본 50). 클수록 DB 왕복이 줄어 빨라져요.
3. 열 매핑 — @Column
무엇: 필드 하나를 테이블의 열 하나에 매핑하고, 그 열의 성질(이름·길이·NULL 허용 등)을 정합니다.
@Column( name = "userid", // 열 이름(생략 시 필드 이름) nullable = false, // NULL 허용 여부(기본 true = 허용) length = 100, // 문자열 길이(기본 255) unique = true, // 유니크 제약(기본 false) insertable = true, // INSERT 문에 포함(기본 true) updatable = true // UPDATE 문에 포함(기본 true) ) private String userid;
@Column 속성 전부와 기본값입니다(Jakarta Persistence 3.1 기준).
| 속성 | 뜻 | 기본값 |
|---|---|---|
name | 열 이름 | 필드 이름 |
nullable | NULL 허용 | true |
length | 문자열 길이(varchar) | 255 |
unique | 유니크 제약 | false |
insertable | INSERT 문에 포함 | true |
updatable | UPDATE 문에 포함 | true |
precision | 숫자 전체 자릿수(decimal) | 0 |
scale | 소수점 이하 자릿수 | 0 |
columnDefinition | DDL의 열 정의를 직접 지정 | ""(없음) |
table | 다른 보조 테이블 지정 | 기본(주) 테이블 |
precision/scale은BigDecimal같은 숫자 열에서만 의미가 있어요. 예:precision = 10, scale = 2는numeric(10,2)(정수부 8, 소수부 2).insertable = false, updatable = false는 "이 열은 JPA가 쓰지 말고 읽기만 하라"는 뜻 — DB 기본값이나 트리거가 채우는 열에 씁니다.columnDefinition은 자동 DDL을 쓸 때 열 타입을 통째로 적습니다. 단, 우리 프로젝트는 Liquibase가 스키마를 관리하므로 이 속성은 거의 쓰지 않아요.
붙이지 않아도 됩니다. 그러면 필드 이름이 곧 열 이름이 되고, 길이 255 · NULL 허용 등 위 표의 기본값이 그대로 적용돼요. 기본값과 다르게 가고 싶을 때만 속성을 답니다.
4. 필드 타입 매핑
무엇: 자바의 어떤 타입이 DB의 어떤 형태로 저장될지입니다. 대부분은 별도 표시 없이 자동으로 매핑돼요.
| 자바 타입 | 설명 |
|---|---|
기본형 / 래퍼 (int, Long, boolean, double …) | 그대로 숫자·불리언 열로 매핑. 애너테이션 불필요. |
String | varchar 로 매핑(기본 길이 255). |
LocalDate / LocalDateTime / LocalTime | 날짜·시간. 별도 애너테이션 없이 date / timestamp 로 매핑됩니다(권장). |
BigDecimal | 정밀한 숫자. precision/scale 로 자릿수 지정. |
UUID | PostgreSQL 의 uuid 열로 매핑 가능. |
레거시 날짜: 예전 java.util.Date / Calendar 를 쓸 때만 @Temporal(TemporalType.DATE | TIME | TIMESTAMP) 로 어디까지 저장할지 지정합니다. LocalDate 계열은 필요 없어요.
@Enumerated — 열거형 저장
enum 을 어떻게 저장할지 정합니다. 기본값은 ORDINAL(순번 숫자) 인데 이건 위험합니다.
public enum Status { ACTIVE, LOCKED, DELETED }
@Enumerated(EnumType.STRING) // "ACTIVE" 같은 이름 문자열로 저장 (권장)
private Status status;ORDINAL 은 enum의 순서 번호(0,1,2…)를 저장합니다. 나중에 enum 중간에 값을 추가하거나 순서를 바꾸면 번호가 밀려서 기존 데이터의 의미가 통째로 어긋납니다.
항상 EnumType.STRING(이름 그대로 저장)을 쓰는 게 안전해요.
@Lob · @Basic · @Transient
@Lob— 아주 긴 데이터.String에 붙이면 CLOB(긴 텍스트),byte[]에 붙이면 BLOB(이진).@Basic(fetch = FetchType.LAZY, optional = false)— 가장 기본적인 매핑을 세밀 조정.fetch는 즉시(EAGER)/지연(LAZY) 로딩,optional은 NULL 허용 여부. 보통은 생략하며 기본형 필드는 자동으로@Basic으로 간주됩니다.@Transient— "이 필드는 매핑하지 마라(열로 만들지 마라)". 화면 계산용 임시 값처럼 DB에 저장할 필요 없는 필드에 답니다.
@Lob private String description; // 긴 텍스트 → CLOB @Transient private int ageThisYear; // DB에 저장 안 함(계산용)
5. 값 타입 묶기 — @Embeddable · @Embedded
무엇: 주소(시·도·우편번호)처럼 늘 함께 다니는 필드 묶음을 작은 클래스로 빼서 재사용합니다. 별도 테이블이 아니라 같은 테이블 안의 여러 열로 펼쳐져요.
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
@Entity
public class User {
@Embedded
private Address address; // city, street, zipcode 열이 tb_user 안에 생김
}같은 @Embeddable 을 두 군데(예: 집 주소, 회사 주소) 넣으면 열 이름이 겹칩니다. 이때 @AttributeOverride (여러 개면 @AttributeOverrides)로 열 이름을 따로 지정해요.
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "city", column = @Column(name = "company_city")),
@AttributeOverride(name = "street", column = @Column(name = "company_street"))
})
private Address companyAddress;6. 복합 기본키 — @EmbeddedId · @IdClass
무엇: 기본키가 열 하나가 아니라 두 개 이상일 때(예: 사용자-역할 연결 테이블) 쓰는 두 가지 방식입니다.
| 방식 | 키를 담는 곳 | 접근 |
|---|---|---|
@EmbeddedId | 키 묶음을 @Embeddable 클래스로 만들어 엔티티에 한 필드로 둠 | user.getId().getRoleCode() 처럼 객체로 묶어 접근 |
@IdClass | 키 필드들을 엔티티에 그대로 펼쳐 두고, 별도 키 클래스를 @IdClass 로 지정 | user.getRoleCode() 처럼 평평하게 접근 |
@Embeddable
public class UserRoleId {
private UUID userId;
private String roleCode;
}
@Entity
public class UserRole {
@EmbeddedId
private UserRoleId id;
}study-backend 의 UserRole·MenuRole 이 실제로 @EmbeddedId 방식을 씁니다.
7. 공통 필드와 상속 매핑
@MappedSuperclass — 공통 필드 부모
무엇: 여러 엔티티가 똑같이 가지는 필드(생성일·수정일 등)를 부모 클래스에 모읍니다. 이 부모는 테이블이 되지 않고, 필드만 자식 테이블로 내려가요.
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
}
@Entity
public class User extends BaseEntity { /* created_at 열을 물려받음 */ }@Inheritance — 진짜 상속을 테이블로
엔티티끼리 부모-자식 상속 관계를 DB에 어떻게 풀어낼지 정합니다. 기본 전략은 SINGLE_TABLE 입니다.
| 전략 | 테이블 구성 | 특징 |
|---|---|---|
SINGLE_TABLE | 부모·자식 전부를 한 테이블에 | 기본값. 빠르지만 자식 고유 열은 NULL이 많아짐. 행을 구분할 @DiscriminatorColumn 필요. |
JOINED | 부모 1개 + 자식마다 테이블, 조인으로 합침 | 정규화가 깔끔. 조회 때 조인 비용. |
TABLE_PER_CLASS | 자식마다 모든 열을 가진 독립 테이블 | 조인이 없지만 공통 키 관리·전체 조회가 까다로움. |
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype") // 어느 자식인지 구분하는 열
public abstract class Payment { }
@Entity
@DiscriminatorValue("CARD") // dtype = 'CARD' 인 행은 이 타입
public class CardPayment extends Payment { }8. 감사(Auditing) — 누가·언제 만들고 고쳤나
무엇: 모든 행에 생성·수정 시각과 작성자를 자동으로 남기는 것. 두 가지 방법이 있어요.
(가) JPA 생명주기 콜백
엔티티가 저장·수정·삭제·조회되는 순간에 자동으로 불리는 메서드를 둘 수 있습니다.
| 콜백 | 불리는 시점 |
|---|---|
@PrePersist / @PostPersist | INSERT 직전 / 직후 |
@PreUpdate / @PostUpdate | UPDATE 직전 / 직후 |
@PreRemove / @PostRemove | DELETE 직전 / 직후 |
@PostLoad | DB에서 읽어 객체에 채운 직후 |
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}(나) Spring Data JPA 방식
스프링이 시각과 작성자를 알아서 채워 주는 더 편한 방법입니다. 설정 한 번 + 애너테이션 몇 개면 끝이에요.
// 1) 설정 클래스에서 한 번 켜기
@Configuration
@EnableJpaAuditing
public class JpaConfig { }
// 2) 공통 부모에 리스너 + 감사 필드
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseAuditEntity {
@CreatedDate private LocalDateTime createdAt; // 생성 시각 자동
@LastModifiedDate private LocalDateTime updatedAt; // 수정 시각 자동
@CreatedBy private String createdBy; // 생성자 자동
@LastModifiedBy private String updatedBy; // 수정자 자동
}@EnableJpaAuditing— 감사 기능을 켭니다(설정에 한 번).@EntityListeners(AuditingEntityListener.class)— 이 엔티티의 저장·수정 때 감사 값을 채우라고 지정.@CreatedDate/@LastModifiedDate— 시각을 자동 기록.@CreatedBy/@LastModifiedBy— 작성자/수정자를 자동 기록(누구인지는AuditorAware구현이 알려 줌).
실제 BaseAuditEntity 는 @MappedSuperclass + @EntityListeners(AuditingEntityListener.class) 로 작성자(@CreatedBy/@LastModifiedBy)는 스프링에 맡기고,
시각은 @PrePersist/@PreUpdate 콜백으로 직접 채웁니다. 작성자가 누구인지는 SpringSecurityAuditorAware 가 로그인 정보에서 가져와요.
전체 예제 — User 엔티티
지금까지의 조각을 모으면 실제 프로젝트의 User 와 비슷한 모습이 됩니다.
import jakarta.persistence.*;
import java.time.LocalDateTime;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseAuditEntity {
@CreatedDate
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
@Entity
@Table(name = "tb_user", schema = "lds")
public class User extends BaseAuditEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "userid", nullable = false, length = 100, unique = true)
private String userid;
@Column(name = "name", nullable = false)
private String name; // length 생략 → varchar(255)
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private Status status; // "ACTIVE" 처럼 이름으로 저장
protected User() { } // JPA가 쓰는 기본 생성자
}한눈에 — 매핑 애너테이션 카탈로그
| 애너테이션 | 용도 | 주요 속성 |
|---|---|---|
@Entity | 테이블에 매핑되는 클래스 선언 | name |
@Table | 테이블 이름·스키마·제약 | name, schema, indexes, uniqueConstraints |
@Id | 기본키 필드 표시 | — |
@GeneratedValue | 기본키 값 자동 생성 | strategy(기본 AUTO), generator |
@SequenceGenerator | 시퀀스 생성기 정의 | name, sequenceName, allocationSize |
@Column | 열 매핑·성질 | name, nullable, length(255), unique, insertable, updatable, precision, scale, columnDefinition |
@Enumerated | enum 저장 방식 | value(STRING 권장 / 기본 ORDINAL) |
@Temporal | 레거시 Date 정밀도 | value(DATE/TIME/TIMESTAMP) |
@Lob | 긴 텍스트/이진(CLOB/BLOB) | — |
@Basic | 기본 매핑 조정 | fetch, optional |
@Transient | 매핑 제외(저장 안 함) | — |
@Embeddable / @Embedded | 값 타입 묶음 정의 / 포함 | — |
@AttributeOverride(s) | 임베디드 열 이름 재지정 | name, column |
@EmbeddedId / @IdClass | 복합 기본키 | — |
@MappedSuperclass | 공통 필드 부모(테이블 아님) | — |
@Inheritance | 상속 전략 | strategy(기본 SINGLE_TABLE) |
@DiscriminatorColumn / @DiscriminatorValue | 상속 행 구분 | name / value |
@EntityListeners | 콜백 리스너 등록 | 리스너 클래스 |
@PrePersist … @PostLoad | 생명주기 콜백 | — |
@CreatedDate / @LastModifiedDate | 생성·수정 시각 자동 | — |
@CreatedBy / @LastModifiedBy | 작성자·수정자 자동 | — |
이 프로젝트의 엔티티는 모두 core/model/entity 패키지에 있고, 전부 jakarta.persistence.* 를 임포트합니다.
공통 감사 필드는 BaseAuditEntity(@MappedSuperclass)로 모았고, UserRole·MenuRole 은 @EmbeddedId 로 복합키를 씁니다.
테이블·스키마(lds)는 Hibernate 자동 생성이 아니라 Liquibase 로 관리해요.
실제 코드와 리포지토리·쿼리는 be-04 데이터 접근, DB·스키마 관리는 be-05 데이터베이스 에서 다룹니다.
다음 단계
- 엔티티 사이의 관계(1:N, N:1, N:M)와 연관관계 매핑 → JPA · 연관관계 매핑