회사에서 JPA 를 안 쓰다보니, 오랜만에 개인 프로젝트를 간단히 만드는데 조금 고생을 하고 있다.(현재진행형)
그래서 이번 기회에 JPA 를 아~주 빠르게 복습할 수 있는 글을 작성해보려 한다.
굉장히 많은 내용을 함축 표현함으로 나만 알아 볼지도 모르는 점 양해바란다.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.dailycode</groupId>
<artifactId>jpa-playground</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.4.2</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>5.6.10.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-hikaricp</artifactId>
<version>5.6.10.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.11</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.22.0</version>
</dependency>
</dependencies>
</project>
작성 경로: src/main/resources/META-INF/persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="jpa_playground">
<properties>
<!-- 필수 속성 -->
<property name="javax.persistence.jdbc.driver" value="org.postgresql.Driver"/>
<property name="javax.persistence.jdbc.user" value="jpa"/>
<property name="javax.persistence.jdbc.password" value="jpa"/>
<property name="javax.persistence.jdbc.url" value="jdbc:postgresql://localhost:5432/jpa_playground"/>
<property name="hibernate.dialect" value="org.hibernate.dialect.PostgreSQL10Dialect"/>
<property name="hibernate.physical_naming_strategy" value="org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy"/>
<!-- 옵션 -->
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.use_sql_comments" value="true"/>
<!-- 자동 DDL 기능 -->
<property name="hibernate.hbm2ddl.auto" value="create" />
</properties>
</persistence-unit>
</persistence>
hibernate-hikaricp
추가하면 자동으로 hikaricp 를 사용한다.작성 경로: src/main/resources/logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(%-4relative) --- [ %thread{10} ]
%cyan(%logger{20}) : %msg%n
</pattern>
</encoder>
</appender>
<logger name="org.hibernate.SQL" level="info"/>
<root level="info">
<appender-ref ref="CONSOLE"/> <!-- Console에 로그를 출력하고자 할 때 사용 -->
</root>
</configuration>
package me.dailycode.main;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class JpaBasicMain {
public static void main(String[] args) {
// persistence.xml ==> <persistence-unit name="jpa_playground">
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpa_playground");
EntityManager em = emf.createEntityManager();
// JPA 에 의한 데이터 변경시 반드시 트랜잭션 내에서 행해야 한다.
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
// JPA 코드 작성 지점
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
이렇게만 하고 실행해도 아래처럼 뭔 정상 작동하는 것을 확인할 수 있다.
Entity
? JPA에서 DB Table 저장되어 있는 데이터를 표현하는 클래스라고 생각하면 된다.
JPA 의 Entity 제약조건
1. 파라미터가 없는 public(또는 protected) 생성자가 필요
2. primary key 필드 위에@Id
명시적 작성
3. final class ❌
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDate;
@Entity
@SequenceGenerator(
schema = "public", // DB 내의 스키마 이름
sequenceName = "employee_seq", // DB 내의 시퀀스 명
name = "public.employee_seq" // JPA 프레임워크가 사용할 시퀀스 명칭
)
@Getter
@NoArgsConstructor
public class Employee {
@Id
@GeneratedValue(strategy = SEQUENCE, generator = "public.employee_seq")
private Long id;
private String name;
private LocalDate hireDate;
public Employee(String name, LocalDate hireDate) {
this.name = name;
this.hireDate = hireDate;
}
public void changeEmpName(String name) {
this.name = name;
}
}
/*
JpaBasicMain.main 문 돌리면 아래와 같이 DDL 이 생성 및 실행됨
Hibernate: create sequence public.employee_seq start 1 increment 50
Hibernate:
create table employee (
id int8 not null,
hire_date date,
name varchar(255),
primary key (id)
)
*/
참고:
@GeneratedValue
(ID 생성전략) 은 이전에 작성한 JPA 이론 공부글에 잘 정리해놨다.
// JPA 코드 작성 지점
// 등록
Employee dailyCode = new Employee("dailyCode", LocalDate.now());
em.persist(dailyCode);
flushAndClear(em);
// 조회 [단건/다건]
Employee employee = em.find(Employee.class, dailyCode.getId());
List<Employee> employeeList = em
.createQuery("select em from Employee em", Employee.class)
.getResultList();
flushAndClear(em);
// 영속성 컨텍스트에 최초로 엔티티가 관리대상이 되면 SnapShot 이 찍히고,
// flush 시점에 해당 엔티티가 SnapShot 과 다르면 update 쿼리가 수행된다.
// 이런 걸 변경감지(Dirty Checking)라고 한다.
Employee findEmployee = em.find(Employee.class, dailyCode.getId());
findEmployee.changeEmpName("changedName");
flushAndClear(em);
Employee removeFind = em.find(Employee.class, dailyCode.getId());
em.remove(removeFind);
tx.commit();
/*
private static void flushAndClear(EntityManager em) {
em.flush();
em.clear();
System.out.println("""
\n!!!! flush has bean done !!!!
""");
}
*/
https://www.baeldung.com/jpa-hibernate-persistence-context 참고
An EntityManager instance is associated with a persistence context. A persistence context is a set of entity instances in which for any persistent entity identity there is a unique entity instance. Within the persistence context, the entity instances and their lifecycle are managed. The EntityManager API is used to create and remove persistent entity instances, to find entities by their primary key, and to query over entities.
Persistence Context 는 DB 에서 조회하거나 변경(등록,수정,삭제)을 일으킬 엔티티 인스턴트를 캐싱하는 공간이며, 이 공간에 들어온 엔티티는 Persistence Context 에 의해서 생명주기가 관리된다. 이 추상적인 캐싱 공간은 애플리케이션과 DB 사이에 존재한다.
참고 : 이 공간에 놓이는 엔티티들은 모두 서로 고유하며, 그 유니크함의 기준은 Entity Class 의 @Id 에 의해서 결정된다.
EntityManager 는 이런 Persistence Context 에 접근 및 제어하기 위한 API 를 제공하는 클래스이다.
Persistence Context 는 엔티티의 생명주기를 관리하게 된다.
그렇다면 생명 주기에는 어떤 것들이 있는가?
비영속(new/transient)
: Persistence Context
와 관련없는 상태영속(managed)
: Persistence Context
에 저장된 상태, em.persist 할 때 발생준영속(detached)
: Persistence Context
에 저장되었다가 분리된 상태Persistence Context
상에서만 삭제된 것으로 이해하면 된다.Persistence Context
에서 지워졌으므로, 어떠한 관리(등록/수정/삭제
)도 발생 ❌삭제(removed)
Persistence Context
에서도 분리간단하게 아래처럼 쿼리를 코드를 돌려서 확인 가능
// JPA 코드 작성 지점
// 등록
Employee dailyCode = new Employee("dailyCode", LocalDate.now());
em.persist(dailyCode);
System.out.println(em.contains(dailyCode)); // true
flushAndClear(em);
Employee findEmployee = em.find(Employee.class, dailyCode.getId());
em.remove(findEmployee);
System.out.println(em.contains(findEmployee)); // false
가장 많이 보는 것만 작성합니다. 세세한건 필요할 때 찾아보기!
Entity Class 예시
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(schema = "public", name = "portal_user")
public class PortalUser {
@Id
private Long id;
@Column(unique = true, nullable = false, length = 100) // length 는 문자열만 가능!
private String nickName;
@Column(name = "user_age", nullable = false)
private Integer age;
@Column(precision = 4, scale = 1) // BigDecimal, BigInteger 만 적용됨! 정밀한 계산에 사용
private BigDecimal bigNum;
@Column(name = "some_num", columnDefinition = "numeric(2,0) not null")
private Integer num;
@Enumerated(EnumType.STRING)
private RoleType roleType;
// 옛날 Date 타입에는 @Temporal 을 사용, 오늘날 LocalDateTime, LocalDate 에는 필요 X
// @Temporal(TemporalType.TIMESTAMP)
// private Date date;
private LocalDate signupDate;
@Lob // 문자열 타입이면 CLOB , 나머지는 BLOB. 참고로 postgresql 의 자동 ddl 은 oid 타입
private String description;
// TABLE 매핑을 원하지 않는 필드의 경우
@Transient
private int temp;
}
DDL 출력 내용
create table public.portal_user (
id int8 not null,
user_age int4 not null,
big_num numeric(4, 1),
description oid,
nick_name varchar(100) not null,
some_num numeric(2,0) not null,
role_type varchar(255),
signup_date date,
primary key (id)
)
@Column(nullable = true)
를 명시적으로 표기하면 실제로도 nullable 한 필드가 된다. 하지만 DB에서 읽어올 때 해당 컬럼이 Null 이면 PropertyAccessException 이 발생한다.자동 생성되는 DDL 쿼리를 그대로 써도 될까?
아니다. 물론 많이 참고할 수는 있다. 하지만 primary key 의 constraint 이름을 주고 하는 등의 작업을 위해서 수동으로 SQL 을 사용해서 테이블을 만들고, 이후에 자동 스키마 생성 기능에서<property name="hibernate.hbm2ddl.auto" value="validate" />
로 바꾼 후, 서버를 기동해서 제대로 매핑됐는지 확인을 한다.
참고: https://app.diagrams.net/ 를 사용해서 ERD 만듦
각 테이블의 ID 는 Sequence 를 따로 생성해서 적용한다.
시퀀스 명은각 테이블 이름 + "_SEQ"
로 할 것이다.
스키마 따로 생성 및 재접속 시 search_path 자동 설정
psql -u postgres // 로그인! postgres=# \c jpa_playground jpa_playground=# create schema if not exists programmer authorization jpa; jpa_playground=# ALTER ROLE jpa SET search_path = programmer,public;
@Entity
@Table(schema = "programmer", name = "project_team")
@SequenceGenerator(
schema = "programmer",
sequenceName = "project_team_seq",
name = "pgmr.project_team_seq"
)
@ToString(exclude = {"programmers"}) @Getter
@NoArgsConstructor
public class ProjectTeam {
public ProjectTeam(String teamName, LocalDate createDate) {
this.teamName = teamName;
this.createDate = createDate;
}
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "pgmr.project_team_seq")
@Column(name = "project_team_id")
private Long id; // 아이디
@Column(nullable = false)
private String teamName; // 프로젝트 팀 이름
@Column(nullable = false)
private LocalDate createDate; // 프로젝트 팀 창단일
@OneToMany(mappedBy = "projectTeam")
private List<Programmer> programmers = new ArrayList<>();
//"ArrayList로 초기화 해두는 것은 JPA 관례로써,
// add할 때 NPE 발생을 막기 위해 사용한다"
// - 김영한 선생님
}
@Entity
@Table(schema = "programmer", name = "programmer")
@SequenceGenerator(
schema = "programmer",
sequenceName = "programmer_seq",
name = "pgmr.programmer_seq"
)
@ToString(exclude = {"projectTeam", "programmerLanguageList"}) @NoArgsConstructor @Getter
public class Programmer {
public Programmer(String name) {
this.name = name;
}
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "pgmr.programmer_seq")
@Column(name = "programmer_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "project_team_id")
private ProjectTeam projectTeam; // 소속 팀
@Column(nullable = false)
private String name; // 프로그래머 이름
@OneToMany(mappedBy = "projectTeam", cascade = CascadeType.ALL)
private List<ProgrammerLanguage> programmerLanguageList = new ArrayList<>();
}
@Entity
@Table(schema = "programmer", name = "programmer_language")
@SequenceGenerator(
schema = "programmer",
sequenceName = "programmer_language_seq",
name = "pgmr.prog_lang_seq"
)
@Getter @ToString(exclude = {"programmer"})
public class ProgrammerLanguage {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "pgmr.prog_lang_seq")
@Column(name = "programmer_language_id")
private Long id;
@ManyToOne(fetch = LAZY, optional = false)
@JoinColumn(name = "programmer_id")
private Programmer programmer;
@ManyToOne(fetch = LAZY, optional = false)
@JoinColumn(name = "language_id")
private Language language;
@Enumerated(STRING)
@Column(name = "proficiency", columnDefinition = "varchar(255) not null default 'BRONZE'")
private LEVEL level = LEVEL.BRONZE;
}
@Entity
@Table(schema = "programmer", name = "language")
@SequenceGenerator(
schema = "programmer",
sequenceName = "language_seq",
name = "pgmr.language_seq"
)
@Getter
@ToString
public class Language {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "pgmr.language_seq")
@Column(name = "language_id")
private Long id;
@Column(nullable = false)
private String languageName; // 프로그래밍 언어 (영어)명
@Column(nullable = false)
private String languageNameKo; // 프로그래밍 언어 한글명
}
참 작성할 게 많다 ^^;;
자동 스키마 생성으로 만들어진 테이블
intellij ultimate 의 diagram 보기 기능을 사용한 것이다.
- 초록색 점 :
NOT NULL
- 노란 열쇠 :
Primary key
- 파란색 열쇠 :
Foreign Key
TIP
: 연관관계의 주인을 지정할때는 외래키가 있는 쪽, 즉 N:1 에서 N쪽을 주인으로 하자.
연관관계 편의 메소드
라는 표현은 "김영한 개발자님"의 저서에 나온 것이다.
공식적인 표현이 아니며, 그냥 양쪽 객체의 참조 필드에 세팅을 해주는 메소드이다.
EXAMPLE
@Entity
public class Programmer {
// ... 일부 생략 ...
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "project_team_id")
private ProjectTeam projectTeam; // 소속 팀
@OneToMany(mappedBy = "programmer")
private List<ProgrammerLanguage> programmerLanguageList = new ArrayList<>();
// 새로운 팀에 배정됨
public void assignedToNewTeam(ProjectTeam projectTeam) {
this.projectTeam = projectTeam;
if (projectTeam != null) {
projectTeam.getProgrammers().add(this);
}
}
// 팀에서 나감
public void outFromTeam() {
if (this.projectTeam != null) {
List<Programmer> programmers = this.projectTeam.getProgrammers();
programmers.remove(this);
this.projectTeam = null;
}
}
}
Programmer dailyCode = new Programmer("dailyCode");
Programmer coolGuy = new Programmer("coolGuy");
Programmer doomGuy = new Programmer("doomGuy");
Programmer hikari = new Programmer("hikari");
ProjectTeam portalService = new ProjectTeam("portal-service", LocalDate.now());
ProjectTeam adminService = new ProjectTeam("admin-service", LocalDate.now());
dailyCode.assignedToNewTeam(portalService);
coolGuy.assignedToNewTeam(portalService);
doomGuy.assignedToNewTeam(portalService);
hikari.assignedToNewTeam(adminService);
// CASCADE ALL 덕분에 ProjectTeam 엔티티 내에 저장된
// Programmer List 의 엔티티도 자동으로 em.persist 된다.
em.persist(portalService);
em.persist(adminService);
em.flush();
em.clear();
ProjectTeam projectTeam = em.find(ProjectTeam.class, portalService.getId());
System.out.println("find programmers!");
for (Programmer programmer : projectTeam.getProgrammers()) {
System.out.println("programmer = " + programmer);
}
1-9
에서는 N:1 에서 N 쪽에 연관관계의 주인을 줬다.
이번에는 조금 다른 다중성의 종류로 연관관계의 주인을 다뤄보자.
그냥 코드로 만 작성함. 보면 암.
예제 도메인(1)
// 1:N 중에서 1 에 해당
@Entity
@Table(schema = "public")
@Getter @Setter
@ToString(exclude = "empList")
public class Company {
@Id @GeneratedValue
private Long id;
private String companyName;
// 자기가 매핑한 테이블에 없는 외래키에 대한 관리가 가능하다.
// 참고로 @OneToMany 에 @JoinColumn 을 작성 안하면 JoinTable 전략이 실행된다. 주의.
@OneToMany
@JoinColumn(name = "team_id")
private List<CompanyEmp> empList = new ArrayList<>();
}
예제 도메인(2)
// 1:N 중에서 N 에 해당
@Entity
@Table(schema = "public")
@Getter @Setter @ToString
public class CompanyEmp {
@Id @GeneratedValue
private Long id;
private String name;
}
JpaBasicMain 코드 작성 및 테스트
CompanyEmp companyEmp = new CompanyEmp();
companyEmp.setName("dailyCode");
em.persist(companyEmp);
Company company = new Company();
company.setCompanyName("Naver");
// 외래키의 주인에 값을 주입
company.getEmpList().add(companyEmp);
em.persist(company);
Hibernate:
/* insert me.dailycode.main.domain._03.CompanyEmp
*/ insert
into
public.company_emp
(name, id)
values
(?, ?)
Hibernate:
/* insert me.dailycode.main.domain._03.Company
*/ insert
into
public.company
(company_name, id)
values
(?, ?)
Hibernate:
/* create one-to-many row me.dailycode.main.domain._03.Company.empList */ update
public.company_emp
set
team_id=?
where
id=?
양방향을 하려면...?
스펙상
1:N
의 양방향 매핑은 존재하지는 않지만, 야매(?)로는 아래처럼 가능하다.
@Entity
@Table(schema = "public")
@Getter @Setter @ToString
public class CompanyEmp {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id", insertable = false, updatable = false)
private Company company;
}
주 테이블에 외래키가 없는 경우로 보겠다.
참고로 주테이블이란 1:1 관계에서 더 자주 Access 하는 테이블을 의미한다.
ex) 사용자(주 테이블) - 회원등급(부 테이블)
예제 도메인(1): 주 테이블
@Entity
@Table(schema = "public", name = "member")
@Getter
@Setter
@ToString
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
@OneToOne(mappedBy = "member")
@ToString.Exclude
private Locker locker;
}
예제 도메인(2): 부 테이블
@Entity
@Getter
@Setter
@ToString
public class Locker {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "locker_id")
@ToString.Exclude
private Member member;
}
예제 도메인
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
private String name;
/*
// 상세하게 작성하면 아래처럼....
// @JoinColumn.referencedColumnName 은 양쪽의 테이블 컬럼 명작성. 생략가능
// @JoinColumn.name 은 연결 테이블에서 사용될 외래키 명이다.
@JoinTable(
name="product_orders",
joinColumns=
@JoinColumn(name="product_id", referencedColumnName="id"),
inverseJoinColumns=
@JoinColumn(name="order_id", referencedColumnName="id")
)
*/
// 간단하게 하면 아래처럼만 해도 됨.
@ManyToMany
@JoinTable(name="product_orders")
private Set<Order> order = new HashSet<>();
}
예제 도메인(2)
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private String orderNum;
@ManyToMany(mappedBy = "order")
private Set<Product> products = new HashSet<>();
}
생성된 테이블 모양새
@Entity
public class Category {
@Id @GeneratedValue
@Column(name = "category_id")
private Long id;
private String categoryNm;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> childList = new ArrayList<>();
}