학습 자료

@Entity — 객체가 표가 된다, 그리고 id 가 필요해진다


Article

14강 끝에서 질문을 던졌다.

내용이 똑같은 책 두 권은, 같은 책인가 다른 책인가.

말로 답하지 말고 양쪽에 직접 물어보자.

자바에게 물어본다

자바 편 14강에서 우리는 Bookequals 를 오버라이딩했다. 제목이 같으면 같은 책이라고 정했다.

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);
SELECT * FROM book
  • 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 다.

이름이 셋이라 헷갈린다
  1. JPA표준. 규칙만 정해뒀다
  2. Hibernate그 규칙을 실제로 구현한 것
  3. Spring Data JPA그것을 더 쉽게 쓰게 감싼 것 (16강)
자바 편 15강의 인터페이스와 구현체 관계 그대로다. JPA 가 인터페이스, Hibernate 가 구현체.

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강에서 우리는 Bookrecord 로 만들었다. 그대로 표식을 붙여보자.

@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 가 데이터베이스에서 책을 읽어올 때 하는 일을 생각해보자.

JPA 가 행 하나를 객체로 만드는 순서
  1. 01

    빈 객체를 먼저 만든다

    new Book() — 인자 없는 생성자를 부른다. 아직 아무 값도 없다.

  2. 02

    값을 하나씩 채워 넣는다

    id 열의 값을 id 필드에, title 열의 값을 title 필드에.

  3. 03

    완성된 객체를 돌려준다

    그래서 만든 뒤에 값을 바꿀 수 있어야 한다.

record 는 이 순서를 따를 수 없다. 생성자로만 만들 수 있고, 만든 뒤에는 못 바꾼다.

자바 편 12강에서 배웠다. "record 의 필드는 전부 private 이고 한 번 정해지면 바뀌지 않는다." 그 장점이 여기서는 걸림돌이 된다.

IdentifierGenerationException 도 같은 뿌리다. id 를 데이터베이스가 붙여주려면, 저장한 뒤에 그 번호를 객체에 써넣어야 한다. record 에는 써넣을 방법이 없다.

그럼 10강은 틀렸던 건가

아니다. 10강의 원칙은 그대로 살아 있다.

10강은 이렇게 말했다 — "밖으로 내보낼 객체는 record 로 만든다." 나갈 때는 접근자로 꺼내고, 들어올 때는 생성자로 채운다. 그 일에는 record 가 여전히 최고다.

지금 필요한 것은 다른 물건이다. 데이터베이스에 앉는 객체다.

두 가지 객체는 하는 일이 다르다
밖으로 오가는 객체데이터베이스에 앉는 객체
무엇인가JSON 이 되었다 풀렸다 한다표의 행 하나에 대응한다
누가 만드나Jackson (10 · 11강)JPA · Hibernate
어떻게 만드나생성자로 한 번에빈 객체를 만든 뒤 채운다
모양record클래스
이름DTO엔티티
지금은 Book 하나가 두 역할을 겸하고 있다. 19강에서 둘로 나눈다.

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 가 없다우리가 정하는 값이 아니기 때문이다
setter 는 안 만들었다. 바꿀 일이 생기면 그때 뜻이 담긴 메서드를 만든다.

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 밖에 없다.

./gradlew bootRun
  • ***************************
  • APPLICATION FAILED TO START
  • SchemaManagementException: Schema validation: missing column [cost] in table [book]

기동 자체가 막힌다. 6강에서 본 그 방식이다. 잘못된 채로 뜨느니 안 뜨는 편이 낫다.

정리하면

데이터베이스는 내용이 아니라 번호로 행을 가리고, 그 번호를 담을 자리가 @Id 다.

다시 짚어보기
  1. 01

    내용이 같아도 다른 행이다

    자바의 equals 는 내용을 보고 같다고 하지만, 데이터베이스는 번호로 가린다. 서점의 같은 책 두 권은 각각이다.

  2. 02

    @Entity 는 클래스와 표를 잇는 표식이다

    자바 편 21강대로 표식은 아무 일도 안 한다. Hibernate 가 읽어야 뜻이 생긴다.

  3. 03

    번호는 데이터베이스가 붙인다

    @GeneratedValue 를 붙이면 persist 전에는 null, 후에는 값이 들어온다.

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
  4. 04

    record 는 엔티티가 될 수 없다

    빈 객체를 만든 뒤 값을 채워야 하는데 record 는 못 바꾼다. 기동은 되고 쓸 때 터진다.

  5. 05

    record 는 여전히 밖으로 나가는 객체의 몫이다

    엔티티는 클래스, DTO 는 record. 19강에서 둘로 나눈다.

이제 Book 이 표와 이어졌다. 그런데 저장하고 읽는 코드가 아직 험하다.

em.getTransaction().begin();
em.persist(book);
em.getTransaction().commit();

트랜잭션을 열고, 넣고, 닫는다. 한 권 저장하는 데 세 줄이다. 목록을 읽으려면 질의문을 문자열로 적어야 한다.

1강에서 ServerSocket 으로 서버를 짜본 뒤 4강에서 다시는 안 쓰게 됐던 것을 기억하는지. 여기서도 같은 일이 벌어진다.

16강에서 인터페이스 하나를 적는다. 그 안은 텅 비어 있다. 메서드를 하나도 구현하지 않는다. 그런데 저장하고 읽는 일이 전부 된다.

자바 편 15강에서 이렇게 배웠다 — "인터페이스는 new 할 수 없다." 그 말이 어떻게 뒤집히는지 본다.

@Entity — 객체가 표가 된다, 그리고 id 가 필요해진다 — 디코드랩(DCODELAB)