프레임워크 · JPA · 엔티티 매핑

JPA · 엔티티 매핑

ORM? 은 자바 객체와 DB 테이블을 자동으로 이어 주는 기술이고, JPA? 는 그 자바 표준입니다. 이 표준대로 어떤 클래스의 어떤 필드가 어느 테이블의 어느 열에 들어가는지 정해 주는 일이 엔티티 매핑이에요.

자바 객체는 "사람", DB 테이블은 "주민등록 명부의 한 줄"이라고 생각하면, 엔티티 매핑은 "이름은 name 칸에, 나이는 age 칸에" 하고 빈칸과 속성을 짝지어 주는 양식 설계와 같아요. 한 번 양식을 정해 두면 JPA가 알아서 그에 맞는 SQL을 만들어 줍니다.
버전 — 이 문서가 기준으로 삼는 것

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 예요.

전략방식특징
IDENTITYDB의 auto-increment 열에 맡김가장 쉬움. 단, INSERT를 해야 키를 알 수 있어 여러 건을 한 번에 모아 보내는 배치 INSERT가 막힙니다.
SEQUENCEDB 시퀀스 객체에서 번호를 받아옴미리 번호를 당겨올 수 있어 성능에 유리. 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 — 이 생성기의 별명. @GeneratedValuegenerator 와 이름이 같아야 연결됩니다.
  • 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열 이름필드 이름
nullableNULL 허용true
length문자열 길이(varchar)255
unique유니크 제약false
insertableINSERT 문에 포함true
updatableUPDATE 문에 포함true
precision숫자 전체 자릿수(decimal)0
scale소수점 이하 자릿수0
columnDefinitionDDL의 열 정의를 직접 지정""(없음)
table다른 보조 테이블 지정기본(주) 테이블
  • precision/scaleBigDecimal 같은 숫자 열에서만 의미가 있어요. 예: precision = 10, scale = 2numeric(10,2)(정수부 8, 소수부 2).
  • insertable = false, updatable = false 는 "이 열은 JPA가 쓰지 말고 읽기만 하라"는 뜻 — DB 기본값이나 트리거가 채우는 열에 씁니다.
  • columnDefinition 은 자동 DDL을 쓸 때 열 타입을 통째로 적습니다. 단, 우리 프로젝트는 Liquibase가 스키마를 관리하므로 이 속성은 거의 쓰지 않아요.
@Column 을 아예 생략하면?

붙이지 않아도 됩니다. 그러면 필드 이름이 곧 열 이름이 되고, 길이 255 · NULL 허용 등 위 표의 기본값이 그대로 적용돼요. 기본값과 다르게 가고 싶을 때만 속성을 답니다.

4. 필드 타입 매핑

무엇: 자바의 어떤 타입이 DB의 어떤 형태로 저장될지입니다. 대부분은 별도 표시 없이 자동으로 매핑돼요.

자바 타입설명
기본형 / 래퍼 (int, Long, boolean, double …)그대로 숫자·불리언 열로 매핑. 애너테이션 불필요.
Stringvarchar 로 매핑(기본 길이 255).
LocalDate / LocalDateTime / LocalTime날짜·시간. 별도 애너테이션 없이 date / timestamp 로 매핑됩니다(권장).
BigDecimal정밀한 숫자. precision/scale 로 자릿수 지정.
UUIDPostgreSQL 의 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 은 쓰지 마세요

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 / @PostPersistINSERT 직전 / 직후
@PreUpdate / @PostUpdateUPDATE 직전 / 직후
@PreRemove / @PostRemoveDELETE 직전 / 직후
@PostLoadDB에서 읽어 객체에 채운 직후
@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 구현이 알려 줌).
study-backend 는 두 방식을 섞어 씁니다

실제 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
@Enumeratedenum 저장 방식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 데이터베이스 에서 다룹니다.

다음 단계