@Entity — 객체가 표가 된다, 그리고 id 가 필요해진다
Article
14강 끝에서 질문을 던졌다.
내용이 똑같은 책 두 권은, 같은 책인가 다른 책인가.
말로 답하지 말고 양쪽에 직접 물어보자.
자바에게 물어본다
자바 편 14강에서 우리는 Book 의 equals 를 오버라이딩했다. 제목이 같으면 같은 책이라고 정했다.
Book b1 = new Book("자바");
Book b2 = new Book("자바");
- b1.equals(b2) = true
- b1 == b2 = false
equals 는 같다고 한다. 우리가 그렇게 만들었으니까. == 는 다르다고 한다. 자바 편 4강대로 주소가 다르니까.
데이터베이스에게 물어본다
같은 책을 두 번 넣어봤다.
INSERT INTO book (title, price) VALUES ('자바', 20000);
INSERT INTO book (title, price) VALUES ('자바', 20000);
- ID | TITLE | PRICE
- 3 | 자바 | 20000
- 4 | 자바 | 20000
- (2 rows, 13 ms)
두 행이 생겼다. 데이터베이스는 "같은 책"이라고 하지 않았다. 그냥 두 권을 받아 적었고, 서로 다른 번호를 붙였다.
어느 쪽이 옳은가
둘 다 옳다. 다만 다른 질문에 답하고 있다.
자바의 equals
내용이 같은가
- 제목이 같으면 같은 책
- 책의 정보를 비교한다
- "이 두 책은 같은 제목이네"
- 복사본과 원본을 구별하지 않는다
데이터베이스의 id
그 물건이 그 물건인가
- 번호가 같아야 같은 책
- 책의 정체를 가린다
- "3번 책과 4번 책은 다른 물건이다"
- 서점 선반의 두 권은 각각이다
손님이 3번 책을 반품하겠다고 하면, 4번 책이 아니라 3번 책을 처리해야 한다. 내용만으로는 둘을 가릴 수 없다.
그래서 표에는 반드시 번호가 필요하다. 14강의 CREATE TABLE 에 이렇게 적었다.
id BIGINT AUTO_INCREMENT PRIMARY KEY
PRIMARY KEY 는 이 열이 행의 정체다라는 선언이다. 중복될 수 없고, 비어 있을 수 없다.
13강에서 우리는 인덱스가 밀리는 것을 보고 번호표를 붙였다. 그때는 편의였다. 데이터베이스에서는 필수다.
객체와 표를 잇는 것 — JPA
이제 자바의 Book 객체와 book 표를 연결해야 한다. 그 일을 ORM 이라고 부른다.
자바에는 그 방법을 정해둔 표준이 있다. 이름이 JPA 다. 그리고 JPA 를 실제로 구현한 프로그램이 Hibernate 다.
- JPA표준. 규칙만 정해뒀다
- Hibernate그 규칙을 실제로 구현한 것
- Spring Data JPA그것을 더 쉽게 쓰게 감싼 것 (16강)
build.gradle 에서 14강에 넣은 jdbc 를 JPA 로 바꾼다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
띄우면 로그에 Hibernate 가 인사한다.
- INFO --- org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.1.Final
- INFO --- Database dialect: H2Dialect
@Entity 를 붙인다. record 에.
10강에서 우리는 Book 을 record 로 만들었다. 그대로 표식을 붙여보자.
@Entity
public record Book(@Id Long id, String title, int price) { }
@Entity 는 **"이 클래스는 표 하나에 대응한다"**는 표식이다. @Id 는 **"이 필드가 번호표다"**라는 표식이다.
띄웠다. 기동이 성공한다. 다만 로그에 조용한 한 줄이 지나간다.
- INFO --- HHH000182: No default (no-argument) constructor for class
- [kr.dcodelab.bookstore.Book] (class must be instantiated by Interceptor)
- INFO --- Started BookstoreApplication in 3.596 seconds
INFO 다. 경고도 아니다. 그냥 지나간다. 그리고 실제로 쓰는 순간 터진다.
- ### 읽기 실패: InstantiationException
- ### No default constructor for entity 'kr.dcodelab.bookstore.Book'
- ### 저장 실패: IdentifierGenerationException
- ### Identifier of entity 'Book' must be manually assigned before calling 'persist()'
record 는 왜 엔티티가 될 수 없나
에러 메시지가 답을 다 말해준다. 기본 생성자가 없다.
JPA 가 데이터베이스에서 책을 읽어올 때 하는 일을 생각해보자.
- 01
빈 객체를 먼저 만든다
new Book()— 인자 없는 생성자를 부른다. 아직 아무 값도 없다. - 02
값을 하나씩 채워 넣는다
id 열의 값을 id 필드에, title 열의 값을 title 필드에.
- 03
완성된 객체를 돌려준다
그래서 만든 뒤에 값을 바꿀 수 있어야 한다.
자바 편 12강에서 배웠다. "record 의 필드는 전부 private 이고 한 번 정해지면 바뀌지 않는다." 그 장점이 여기서는 걸림돌이 된다.
IdentifierGenerationException 도 같은 뿌리다. id 를 데이터베이스가 붙여주려면, 저장한 뒤에 그 번호를 객체에 써넣어야 한다. record 에는 써넣을 방법이 없다.
그럼 10강은 틀렸던 건가
아니다. 10강의 원칙은 그대로 살아 있다.
10강은 이렇게 말했다 — "밖으로 내보낼 객체는 record 로 만든다." 나갈 때는 접근자로 꺼내고, 들어올 때는 생성자로 채운다. 그 일에는 record 가 여전히 최고다.
지금 필요한 것은 다른 물건이다. 데이터베이스에 앉는 객체다.
| 밖으로 오가는 객체 | 데이터베이스에 앉는 객체 | |
|---|---|---|
| 무엇인가 | JSON 이 되었다 풀렸다 한다 | 표의 행 하나에 대응한다 |
| 누가 만드나 | Jackson (10 · 11강) | JPA · Hibernate |
| 어떻게 만드나 | 생성자로 한 번에 | 빈 객체를 만든 뒤 채운다 |
| 모양 | record | 클래스 |
| 이름 | DTO | 엔티티 |
record 를 버리는 게 아니라, 역할이 하나 더 생긴 것이다. 19강에서 이 표를 다시 펼친다.
클래스로 다시 만든다
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int price;
protected Book() { } // JPA 가 쓴다
public Book(String title, int price) {
this.title = title;
this.price = price;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public int getPrice() { return price; }
}
네 곳을 짚어보자.
| 적은 것 | 왜 |
|---|---|
@Entity | 이 클래스가 book 표에 대응한다. 클래스 이름이 곧 표 이름이다 |
@Id | 이 필드가 PRIMARY KEY 다 |
@GeneratedValue(strategy = IDENTITY) | 번호는 데이터베이스가 붙인다. 14강의 AUTO_INCREMENT 를 쓰겠다는 뜻 |
protected Book() { } | JPA 가 빈 객체를 만들 때 쓴다. protected 라 우리 코드에서는 못 부른다 |
생성자에 id 가 없다 | 우리가 정하는 값이 아니기 때문이다 |
id 를 만드는 생성자가 없다는 것이 중요하다. 13강에서 **"번호를 붙이는 것은 서버의 권한"**이라고 했는데, 이제 그 권한이 한 칸 더 내려가 데이터베이스의 권한이 됐다.
저장해봤다.
- ### persist 전 id = null
- ### persist 후 id = 5
저장하기 전에는 null 이고, 저장한 뒤에 번호가 생겼다. 우리가 넣은 게 아니다. 데이터베이스가 붙여준 번호를 JPA 가 객체에 써넣었다. record 였다면 이 마지막 한 걸음을 못 한다.
읽어보면 아까 넣은 두 권도 그대로 있다.
- ### 행: id=3 자바 20000
- ### 행: id=4 자바 20000
- ### 행: id=5 코틀린 25000
매핑이 표와 맞는지 검사시킨다
엔티티는 자바 파일이고, 표는 데이터베이스 안에 있다. 둘이 어긋나면 어떻게 되나.
application.properties 에 한 줄을 적으면 기동할 때 대조해준다.
spring.jpa.hibernate.ddl-auto=validate
검사만 한다는 뜻이다. 표를 만들지도, 지우지도 않는다.
정말 검사하는지 확인하려고 일부러 어긋내봤다. 엔티티는 cost 라는 열을 찾는데, 표에는 price 밖에 없다.
- ***************************
- APPLICATION FAILED TO START
- SchemaManagementException: Schema validation: missing column [cost] in table [book]
기동 자체가 막힌다. 6강에서 본 그 방식이다. 잘못된 채로 뜨느니 안 뜨는 편이 낫다.
정리하면
데이터베이스는 내용이 아니라 번호로 행을 가리고, 그 번호를 담을 자리가 @Id 다.
- 01
내용이 같아도 다른 행이다
자바의
equals는 내용을 보고 같다고 하지만, 데이터베이스는 번호로 가린다. 서점의 같은 책 두 권은 각각이다. - 02
@Entity는 클래스와 표를 잇는 표식이다자바 편 21강대로 표식은 아무 일도 안 한다. Hibernate 가 읽어야 뜻이 생긴다.
- 03
번호는 데이터베이스가 붙인다
@GeneratedValue를 붙이면persist전에는null, 후에는 값이 들어온다.@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - 04
record는 엔티티가 될 수 없다빈 객체를 만든 뒤 값을 채워야 하는데
record는 못 바꾼다. 기동은 되고 쓸 때 터진다. - 05
record는 여전히 밖으로 나가는 객체의 몫이다엔티티는 클래스, DTO 는
record. 19강에서 둘로 나눈다.
이제 Book 이 표와 이어졌다. 그런데 저장하고 읽는 코드가 아직 험하다.
em.getTransaction().begin();
em.persist(book);
em.getTransaction().commit();
트랜잭션을 열고, 넣고, 닫는다. 한 권 저장하는 데 세 줄이다. 목록을 읽으려면 질의문을 문자열로 적어야 한다.
1강에서 ServerSocket 으로 서버를 짜본 뒤 4강에서 다시는 안 쓰게 됐던 것을 기억하는지. 여기서도 같은 일이 벌어진다.
16강에서 인터페이스 하나를 적는다. 그 안은 텅 비어 있다. 메서드를 하나도 구현하지 않는다. 그런데 저장하고 읽는 일이 전부 된다.
자바 편 15강에서 이렇게 배웠다 — "인터페이스는 new 할 수 없다." 그 말이 어떻게 뒤집히는지 본다.